package com.appspot.theonionorthenews;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;

import javax.persistence.EntityManager;
import javax.persistence.EntityTransaction;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.*;

import com.google.appengine.api.memcache.MemcacheService;
import com.google.appengine.api.memcache.MemcacheServiceFactory;

@SuppressWarnings("serial")
public class OnionOrNewsServlet extends BaseServlet {

  /** An exception thrown by {@link findItem} for bad input. */
  private static class NoSuchItemException extends Exception {
    public NoSuchItemException(String type, String id) {
      super(String.format("No such item for %s %s", type, id));
    }
  }

  /** Substitution token for the id of the item being asked. */
  private static final String ID_TOKEN = "ID";

  /** Substitution for the id-specific URL of the item being asked. */
  private static final String ID_URL_TOKEN = "ID_URL";

  /** Substitution token for the quotation being asked. */
  private static final String QUOTE_TOKEN = "QUOTE";

  /** Substitution token for a "You're right!" or "Sorry, wrong" message. */
  private static final String RIGHTWRONG_TOKEN = "RIGHT_OR_WRONG";

  /** Substitution token for a number of correct guesses. */
  private static final String CORRECTCOUNT_TOKEN = "CORRECT_COUNT";

  /** Substitution token for a total number of guesses. */
  private static final String TOTALCOUNT_TOKEN = "TOTAL_COUNT";

  /** Substitution token for a percentage of correct guesses. */
  private static final String PERCENTCORRECT_TOKEN = "PERCENT_CORRECT";

  /** Substitution for the URL confirming the story's (in)authenticity. */
  private static final String LAST_URL_TOKEN = "LAST_URL";

  /** Substitution for the URL of the previous story. */
  private static final String LAST_ID_URL_TOKEN = "LAST_ID_URL";

  /** Parameter present when the user guesses it's an Onion story. */
  private static final String ONION_PARAMETER = "onion";

  /** We need at least this many guesses to decide something is too easy. */
  private static final long MIN_GUESS_THRESHOLD = 100;

  /** We need at least this many percent correct guesses to decide something is too easy. */
  private static final double TOO_EASY_THRESHOLD = 80;

  /** Our HTML initial ask page, assumed small enough to cache in memory. */
  static String ask;

  /** Our HTML confirm-and-reask page, also assumed small enough to cache in memory. */
  static String reask;

  /**
   * A memcache service.  Combined with a per-thread/per-hit entity manager,
   * this gets us a {@link CachedNewsStorage} object to access storage, but
   * we don't need to keep recreating the cache service.
   */
  MemcacheService cache;

  /** Initialization loads the header and footer fragments, which are held forever. */
  @Override
  public void init(ServletConfig config) throws ServletException {
    try {
      ask = readStringFromResource("ask.html");
    } catch (IOException e) {
      throw new ServletException("Could not load ask page", e);
    }
    try {
      reask = readStringFromResource("reask.html");
    } catch (IOException e) {
      throw new ServletException("Could not load reask page", e);
    }
    cache = MemcacheServiceFactory.getMemcacheService();
  }

  /** doGet offers the "first ask" page. */
  @Override
  public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    CachedNewsStorage store = null;
    try {
      store = new CachedNewsStorage(getEntityManager(), cache);
    } catch (CachedNewsStorage.BadGroupException e) {
      sendTextError(resp, HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
    }

    NewsItemEntity item = null;
    try {
      item = findItem(req, store);
    } catch (NoSuchItemException e) {
      sendTextError(resp, HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
      return;
    }

    resp.setContentType("text/html");
    if (item == null) {
      resp.setStatus(HttpServletResponse.SC_OK);
      PrintWriter pw = resp.getWriter();
      pw.println("<html><head><title>No news is no news!</title></head><body>");
      pw.println("No news items defined yet.  Try <a href='/new'>making one</a>.");
      pw.print("</body></html>");
      return;
    }

    Map<String, String> substitutions = new HashMap<String, String>(3);
    substitutions.put(QUOTE_TOKEN, item.getQuote());
    substitutions.put(ID_TOKEN, Long.toString(item.getId(), ID_RADIX));
    substitutions.put(ID_URL_TOKEN, idURL(req, item.getId()));
    renderForm(resp.getWriter(), ask, req.getRequestURI(), substitutions);
  }

  /** doPost offers the "confirm-and-reask" page. */
  @Override
  public void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    EntityManager em = getEntityManager();
    CachedNewsStorage store = null;
    try {
      store = new CachedNewsStorage(em, cache);
    } catch (CachedNewsStorage.BadGroupException e) {
      sendTextError(resp, HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
    }

    NewsItemEntity last = null;
    String rightOrWrongText = "You're right!";

    EntityTransaction txn = em.getTransaction();
    txn.begin();
    try {
      try {
        last = findItem(req, store);
      } catch (NoSuchItemException e) {
        // leave it as null; we'll use the ask form instead.
      }

      if (last != null) {
        boolean userGuessedTrue =
            (nullOrString(req.getParameter(ONION_PARAMETER)) == null);
        if (userGuessedTrue == last.isTrue()) {
          last.incrementGuesses(true);
          if (last.getGuessCount() > MIN_GUESS_THRESHOLD &&
              last.getCorrectGuessCount() * 100 / last.getGuessCount() > TOO_EASY_THRESHOLD) {
            last.clearDrawId();
          }
        } else {
          last.incrementGuesses(false);
          rightOrWrongText = "Sorry, no.";
        }
        store.update(last);
        txn.commit();
      }
    } finally {
      if (txn.isActive()) {
        txn.rollback();
      }
    }
    NewsItemEntity item = store.getRandomDraw();
    if (last != null && last.getId() == item.getId()) {
      // Check to be sure we get a *different* item for the next question.
      for (int i = 0; i < 10; i++) {
        item = store.getRandomDraw();
        if (last.getId() != item.getId()) {
          break;
        }
      }
    }

    resp.setContentType("text/html");

    Map<String, String> substitutions = new HashMap<String, String>(6);
    substitutions.put(RIGHTWRONG_TOKEN, rightOrWrongText);
    if (last != null) {
      substitutions.put(LAST_URL_TOKEN, last.getUrl());
      substitutions.put(LAST_ID_URL_TOKEN, idURL(req, last.getId()));
      substitutions.put(CORRECTCOUNT_TOKEN, Long.toString(last.getCorrectGuessCount()));
      substitutions.put(TOTALCOUNT_TOKEN, Long.toString(last.getGuessCount()));
      substitutions.put(PERCENTCORRECT_TOKEN, String.format("%.02f%%",
          100 * ((double) last.getCorrectGuessCount()) / last.getGuessCount()));
    }
    substitutions.put(QUOTE_TOKEN, item.getQuote());
    substitutions.put(ID_TOKEN, Long.toString(item.getId(), ID_RADIX));
    substitutions.put(ID_URL_TOKEN, idURL(req, item.getId()));

    renderForm(resp.getWriter(), last == null ? ask : reask,
        req.getRequestURI(), substitutions);
  }

  /**
   * Looks up a news item by request parameters.  If either id or draw
   * parameters are found, looks up that item; otherwise, picks a random
   * one from the drawable items, or returns {@code null} if none can be found.  
   *
   * @param req the request, to parse parameters from it.
   * @param store the storage to look in
   * @return the chosen {@link NewsItemEntity}, or {@code null} for a valid request
   * with no {@code NewsItem}s in storage.
   * @throws NoSuchItemException for a bad request, matching no items.
   */
  private static NewsItemEntity findItem(HttpServletRequest req, CachedNewsStorage store) 
      throws NoSuchItemException {
    String drawString = nullOrString(req.getParameter(DRAW_PARAM));
    String idString = nullOrString(req.getParameter(ID_PARAM));

    NewsItemEntity item = null;
    if (drawString == null && idString == null) {
      item = store.getRandomDraw();
    } else {
      if (idString != null && drawString != null) {
        throw new NoSuchItemException("bad query:",
            String.format("Don't use both %s and %s", ID_PARAM, DRAW_PARAM));
      }

      // fetch a specific item, but by which ID?
      String type = null;
      String paramVal = null;
      if (idString != null) {
        type = "id";
        paramVal = idString;
        try {
          long id = parseId(idString);
          item = store.getById(id);
        } catch (NumberFormatException e) {
          // nevermind, just don't un-null item
        }
      } else {
        type = "draw";
        paramVal = drawString;
        try {
          long drawId = parseId(drawString);
          item = store.getByDrawId(drawId);
        } catch (NumberFormatException e) {
          // nevermind, just don't un-null item
        }
      }
      if (item == null) {
        throw new NoSuchItemException(type, paramVal);
      }
    }

    return item;
  }

  private static long parseId(String idString) throws NumberFormatException {
    return Long.parseLong(idString, ID_RADIX);
  }

  /** Encodes a URL from a base and a long identifier. */
  private static String idURL(HttpServletRequest request, long id) {
    String host = nullOrString(request.getHeader("Host"));

    String protocol = request.getProtocol();  // e.g. "HTTP/1.1", not yet "http"
    protocol = protocol.substring(0, protocol.indexOf('/')).toLowerCase();

    return String.format("%s://%s%s?id=%s", protocol, host,
        request.getRequestURI(), Long.toString(id, ID_RADIX));
  }

  /** Returns {@code null} for input {@code null} or "", else returns input. */
  private static String nullOrString(String string) {
    if (string == null || string.isEmpty()) {
      return null;
    }
    return string;
  }
}
